用 Vue 一陣子之後,大多數人對 computed 的印象是「會快取結果的函式」。這句話不算錯,但只講對了一半。computed 什麼時候重算、什麼時候不算、為什麼有時候明明資料變了畫面卻沒動,這些都要回到它的運作原理才講得清楚。
這篇會從基本用法開始,接著整理它的幾個核心特性,簡單拆解原理,最後列出實務上容易踩到的坑。
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const qty = ref(2)
const total = computed(() => price.value * qty.value)
</script>
<template>
<p>總價:{{ total }}</p>
</template>
幾個基本觀念:
computed() 接收一個 getter 函式,回傳一個唯讀的 ref。<script> 中要用 total.value 取值;在 template 中會自動解包,直接寫 total 就好。先修正一個常見的說法:computed 並不是「函式結果的快取」,而是依據它讀取到的響應式依賴來決定要不要重算。
用 methods 對照最容易看出差別:
<script setup>
import { ref, computed } from 'vue'
const count = ref(1)
const doubleComputed = computed(() => {
console.log('computed 執行')
return count.value * 2
})
function doubleMethod() {
console.log('method 執行')
return count.value * 2
}
</script>
<template>
<p>{{ doubleComputed }} {{ doubleComputed }} {{ doubleComputed }}</p>
<p>{{ doubleMethod() }} {{ doubleMethod() }} {{ doubleMethod() }}</p>
</template>
打開 console 會看到:
computed 執行 只出現 1 次
method 執行 出現 3 次
只要 count 沒變,不論讀幾次 computed,都直接拿上次的結果。
這裡的關鍵字是響應式。非響應式的值就算改變,也不會觸發重算:
const price = ref(100)
let tax = 1.05 // 普通變數
const total = computed(() => price.value * tax)
price.value = 200 // ✅ 重算:price 是 ref,Vue 追蹤得到
tax = 1.1 // ❌ 不重算:Vue 根本不知道 tax 變了
computed 不會在依賴改變的當下馬上重算。它只做一件事:把自己標記為「過期」。等到下次有人讀取 .value,才真正執行 getter。
const count = ref(1)
const double = computed(() => {
console.log('計算中')
return count.value * 2
})
count.value = 2
count.value = 3
count.value = 4
// 到這裡為止,一次「計算中」都沒印
console.log(double.value) // 這時候才印「計算中」,結果是 8
連續改三次,只算一次。沒有人讀取的 computed,完全不會執行。
computed 追蹤的是上一次執行時實際讀到的響應式資料,不是 getter 裡「寫到」的所有變數。
const isLogin = ref(false)
const user = ref({ name: 'Fabio' })
const message = computed(() => {
if (!isLogin.value) return '請先登入'
return `歡迎,${user.value.name}`
})
當 isLogin 是 false 時,函式在第一個 return 就結束,根本沒讀到 user。所以這時候改 user.value.name,computed 不會重算。
這是刻意的設計:當下用不到的資料,本來就沒必要追蹤。等 isLogin 變成 true 重新執行後,user 才會被收進依賴裡。除錯時如果遇到「明明改了卻沒反應」,可以先想想是不是這個原因。
從 Vue 3.4 開始,computed 重算後如果結果和上次相同,就不會通知依賴它的 effect 更新。
const count = ref(0)
const isEven = computed(() => count.value % 2 === 0)
watchEffect(() => console.log(isEven.value))
count.value = 2 // isEven 還是 true,watchEffect 不會再跑
不過這個比較用的是 Object.is。如果每次都回傳新的物件或陣列,參考不同,就一律視為改變:
// 每次都是新物件,下游每次都會更新
const info = computed(() => ({ isEven: count.value % 2 === 0 }))
3.4 之後,getter 可以透過參數拿到舊值,自行決定要不要沿用:
const info = computed((oldValue) => {
const next = { isEven: count.value % 2 === 0 }
if (oldValue && oldValue.isEven === next.isEven) return oldValue
return next
})
computed 預設是唯讀的,直接賦值會在開發環境跳出警告。需要寫入時,要傳入 get / set:
const firstName = ref('承哲')
const lastName = ref('陳')
const fullName = computed({
get: () => `${lastName.value}${firstName.value}`,
set: (val) => {
lastName.value = val.slice(0, 1)
firstName.value = val.slice(1)
}
})
fullName.value = '王小明' // 觸發 set,改的是原始資料
要注意,setter 的工作是回頭修改原始資料,而不是修改 computed 本身。computed 永遠是從原始資料算出來的。實務上可寫 computed 最常用在搭配 v-model 的元件封裝。
前面介紹 Proxy 與 reactive 時提過兩個核心動作:讀取時收集依賴(track),寫入時觸發更新(trigger)。computed 就是建立在這個機制上,再加一個「是否過期」的旗標。
下面是概念示意,不是 Vue 原始碼。實際實作從 3.4、3.5 以來改成版本號計數等更複雜的方式,但核心想法相同:
function computed(getter) {
let value
let dirty = true // 是否過期,一開始當然是過期的
// 用 effect 包住 getter,讓裡面讀到的響應式資料被收集成依賴
const runner = effect(getter, {
lazy: true, // 先不要執行
scheduler() {
// 依賴改變時不重算,只標記過期,並通知「用到我的人」
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = runner() // 過期才真的重算
dirty = false
}
track(obj, 'value') // 讓外面讀取我的 effect 也能追蹤到我
return value
}
}
return obj
}
對照前面的特性:
| 特性 | 對應的程式 |
|---|---|
| 快取 | dirty 為 false 時直接回傳 value |
| 惰性 | lazy: true,而且 scheduler 只改旗標、不執行 getter |
| 只追蹤響應式資料 | 依賴是靠 Proxy 的 get 攔截收集的,普通變數不會經過 Proxy |
| 動態依賴 | 每次執行 runner() 都重新收集,沒讀到的就不在名單上 |
理解這段之後,第二節那幾個特性就不需要死背了。
// ❌
const total = computed(() => {
count.value++ // 修改其他狀態
fetch('/api/log') // 發請求
return price.value * qty.value
})
從上面的原理可以知道,getter 什麼時候執行、執行幾次,都不是你能控制的(可能不執行,也可能被快取跳過)。把副作用放在這裡,行為會變得無法預測。副作用請交給 watch / watchEffect。
sort()、reverse()、splice() 會直接修改原陣列:
// ❌ list 本身被排序了
const sorted = computed(() => list.value.sort((a, b) => a.price - b.price))
// ✅ 先複製
const sorted = computed(() => [...list.value].sort((a, b) => a.price - b.price))
// ✅ 或用 ES2023 不修改原陣列的方法
const sorted = computed(() => list.value.toSorted((a, b) => a.price - b.price))
這個 bug 很隱蔽,因為畫面看起來「是對的」,但原始資料已經被動過了。
// ❌ 永遠停在第一次讀取的時間
const now = computed(() => Date.now())
Date.now()、Math.random()、localStorage、一般的 let 變數,Vue 都追蹤不到。需要即時更新時,要自己用 ref 搭配計時器或事件更新:
const now = ref(Date.now())
setInterval(() => (now.value = Date.now()), 1000)
// ❌ 拿到的是 Promise,不是資料
const user = computed(async () => await fetchUser(id.value))
computed 必須同步回傳值。非同步資料請用 watch 搭配 ref:
const user = ref(null)
watch(id, async (newId) => {
user.value = await fetchUser(newId)
}, { immediate: true })
或使用 VueUse 的 computedAsync。
computed 的 getter 不接收自訂參數(3.4 以後唯一的參數是舊值)。常見的變通寫法是回傳一個函式:
const getItemTotal = computed(() => (id) => {
const item = list.value.find(i => i.id === id)
return item.price * item.qty
})
getItemTotal.value(3)
但這樣被快取的是那個函式,而不是計算結果。每次呼叫 getItemTotal.value(3) 都會重新 find、重新相乘,效果和一般函式差不多。需要帶參數的運算,直接寫成一般函式通常會更清楚。
const state = reactive({ count: 1 })
const { count } = state // count 只是數字 1,和 state 斷了連結
const double = computed(() => count * 2) // ❌ 永遠是 2
改成直接讀 state.count,或用 toRefs(state) 保留連結。
補充:Vue 3.5 起,在
<script setup>裡解構defineProps會由編譯器自動轉成props.xxx,所以仍然保有響應性。但解構reactive()還是會失效,兩者不要混為一談。
const activeList = computed(() => list.value.filter(i => i.active))
activeList.value.push(newItem) // ❌ 下次重算就消失了
computed 的結果是衍生出來的快照,改它沒有意義。要改就改原始資料 list。
| computed | methods | watch | |
|---|---|---|---|
| 有沒有快取 | 有 | 沒有,每次呼叫都執行 | 不適用 |
| 有沒有回傳值 | 有 | 有 | 沒有 |
| 能不能傳參數 | 不行 | 可以 | 不適用 |
| 能不能非同步 | 不行 | 可以 | 可以 |
| 適合用在 | 從狀態算出新資料 | 事件處理、需要參數的運算 | 資料變了之後要做某件事 |
判斷原則很簡單:要「得到一個值」用 computed,要「做一件事」用 watch。
記住「dirty flag + track / trigger」這個模型,大部分 computed 的行為都能自己推導出來。